React 副作用分割パターン
はじめに
React Hooks自体の解説記事はいくつかあるけど, Hooksの登場によってReactがどう変わったのかについて書かれている記事が無かったので書いた 継承, Mixin, HOC, render props, Hooksの登場によって, Reactの副作用分割パターンがどう変遷していったのか分かるようにすることがこの記事の目的
Counterコンポーネントを例に副作用の分割について考えてみる
要件
ボタンを押すとボタンに表示されたカウントが1増える
上矢印キーを押すとカウントが1増える
まずは必要な副作用を書き出してみる
カウントを保持する状態countとそのupdaterであるincrementCount
window.addEventListenerによるkey bindの登録
分割せずにClass Component内にべた書きした場合
このページで出てくる型について
React.ComponentTypeやReact.ReactElementといったReactのTypeScript向けの型を用いて分割方法を説明していく
https://gyazo.com/55527e8b136e960f94dad5beb1287e2a
継承を使う
副作用を持つ補助クラスを用意し, 副作用を利用する側からはそれらを継承することで副作用を分離するパターン
使い方
1. 副作用ごとに2つの補助クラスを用意する
CountableComponent
countstate, incrementCountメソッドを提供するコンポーネント
React.Componentクラスを継承
KeydownComponent
keydownイベント発生時にlistenerメンバ変数に代入されているリスナを呼び出すコンポーネント
CountableComponentクラスを継承
2. MyCounterクラスからKeydownComponentクラスを継承して副作用を利用する
悪い点
1. props/state/インスタンス変数/インスタンスメソッド/lifecycleメソッドが衝突する可能性がある
例えばkeyupイベント発生時にリスナを呼び出すKeyupComponentを作成したいとする
listenerメンバはKeydownComponentクラスのものと衝突するので使えない
componentDidMountやcomponentWillUnmountメソッドもKeydownComponentクラスのものをoverrideしてしまうので使えない
2. 副作用提供側のstateやメンバ変数の初期化が副作用利用側に露出している
readonly state: State = { count: this.props.defaultCount }の部分
本来defaultCountpropsがcountstateの初期値になることはCountableComponentが知っているはず
理想的にはCountableComponentクラス内部で初期化して欲しい
3. 再利用性が低い
KeyupComponentクラスがCountableComponentクラスに依存してしまっている
カウント機能を持たないKeyupComponentクラスが欲しい時に不便
再利用が低くなってしまっている
本来であればclass MyCounter extends KeydownComponent<Props, State>, CountableComponent<Props, State> { ... }のように書きたい
しかしJavaScriptのclassは多重継承できないため, これが出来ない
また副作用の利用側に依存してしまう可能性がある
CountableComponentからthis.props.xxxでMyCounterコンポーネントのxxxpropsにアクセスできてしまう
MyCounterコンポーネントで継承しないと動作しないCountableComponentが完成してしまう
再利用が困難になる
4. 副作用利用側がclass componentに限定されてしまう
副作用を利用するには継承しなければならない
継承ができないfunction componentからはこのパターンを利用できない
class componentはfunction componentよりも冗長なので, このパターンを使用したコードも全体的に冗長になりがち
加えてthis問題と向き合わないといけない
5. 型注釈が複雑
Q. 何故このような複雑な型注釈が必要なのか?
A. 副作用を利用する側のコンポーネントのpropsやstateの型をジェネリクスで表現しなければならないため
ジェネリクスを使わないと MyCounterクラスに新たなpropsやstateを定義できなくなる
例: 増加量を指定するためにMyCounterコンポーネントにamountpropsを追加したい
以下のようにclass CountableComponent<P extends Props, S extends State>のジェネリクスを消し去った状態で実装する
React.Component<Props, State>のPropsにamountpropsが含まれていないため, コンパイルエラーが出る
https://gyazo.com/79e6f7ecc6a63f1a3c6fac4b0570fa4c
6. 同じ副作用を複数回利用できない
カウンタを複数内蔵したDoubleCounterコンポーネントを作成したいとする
CountableComponentを複数回継承して実現したい
JavaScriptでは同じclassを複数回継承できないので不可
MixInを使う
継承の代わりにMixInを使うパターン
初期のReactに存在したReact.createClassAPIのmixinsオプションでMixInがサポートされていた
Reactの公式ブログでは新規開発におけるMixinの使用を非推奨としている
ES6 launched without any mixin support. Therefore, there is no support for mixins when you use React with ES6 classes. We also found numerous issues in codebases using mixins, and don’t recommend using them in the new code.
使い方
1. 副作用ごとにMixin (CounterMixin, KeydownMixin) を用意する
2. MyCounterクラスからCounterMixin, KeydownMixinをmixinして副作用を利用する
良い点
1.副作用提供側のstateやメンバ変数の初期化が副作用利用側に露出していない
CounterMixinのgetInitialStateメソッドにて, Mixin内部で使用するstateの初期化が可能
2. 継承によるパターンよりも再利用性が高い
MyCounterクラスから複数のMixinを利用できる
KeydownMixinがCounterMixinに依存しないよう記述できるようになった
3. lifecycleメソッドの衝突に強い
利用しているMixinのすべてのlifecycleメソッドは合成される
KeyupComponentも同時に使用できる
しかしインスタンス変数などは依然として衝突するのでkeydowListener, keyupListenerのように分ける必要がある
悪い点
1. props/state/インスタンス変数/インスタンスメソッドが衝突する
2. 副作用利用側がclass componentに限定されてしまう
3. 型注釈が複雑
4. 再利用性が低い
継承によるパターンと同様に, 副作用の利用側に依存してしまう可能性がある
5. 同じ副作用を複数回利用できない
6. 静的解析が困難
Mixinは動的に合成されるので実行するまでどのMixinがどの順で適用されるか不明
エディタやIDEによるコードの静的解析と相性が悪い
エディタによるメソッドのrenameや定義行へのジャンプができない可能性がある
mixins: [CounterMixin, KeydownMixin]ならどのMixinがどの順で適用されるか実行しなくても分かるが, mixins: getMixinList(option)は実行しないと分からない
HOCを使う
副作用を利用する側のコンポーネントを副作用を持つコンポーネントでwrapして返すパターン
HOCについて
コンポーネントを引数に取り, コンポーネントを返す関数
type HOC = (option: Option, WrappedComponent: React.ComponentType) => React.ComponentType
WrappedComponentをカスタマイズして返す
HOC: High-Order Component
class componentでWrappedComponentをwrapし, class comonentを返す実装が一般的
class componentで副作用を扱い, 副作用の結果を WrappedComponentのpropsに渡す
良い点
1. 副作用提供側のstateやメンバ変数の初期化が副作用利用側に露出していない
2. Mixinによるパターンよりも再利用性が高い
副作用の利用側に依存してしまう可能性がない
3. props/state/インスタンス変数/インスタンスメソッド/lifecycleメソッドの衝突に強い
4. function componentから副作用を利用できる
5. 同じ副作用を複数回利用できる
6. 静的解析に優しい
悪い点
1. 型が非常に複雑
OriginalProps, ExternalProps, InjectedPropsの3つの型を考えてコードを書く必要がある
https://s3-ap-northeast-1.amazonaws.com/blog-mitsuruog/images/2018/hoc1.png
Q. 何故こうなったのか?
HOCが副作用利用側のコンポーネントをwrapするため
副作用利用側のコンポーネントのpropsやstateの型をジェネリクスで表現しなければならない
少なくともwithXXX(option, WrappedComponent: React.Component<P, S>)のPとSをジェネリクスで記述する必要がある
2. 型推論が効きにくい
withKeydownの型パラメータにInjectedPropsが必要
理想的にはwithCounterでInjectedPropsが注入されることは分かっているので, 型推論されるはず
しかしTypeScriptではこれを型推論してくれないため, 型パラメータを明示的に指定しなければならない
3. ネストが深くなりがち
HOCが増えるとwrap回数が増えてネストが深くなる
いわゆるコールバック地獄
https://cdn-images-1.medium.com/max/800/1*Co0gr64Uo5kSg89ukFD2dw.jpeg
関数型プログラミングでよく使われる合成関数というテクニック
が, そもそもこうしたUtilityを使わないと書きにくいこと自体あまり良くない
4. 副作用の適用順序の入れ替えが大変
ネストしてるのでHOCの適用順序を入れ替えるのが大変
例えば中のHOCを1段外に出すには
① 開き括弧と閉じ括弧, HOCのオプション, HOC名をそれぞれ削除する
② 1段外側に削除したものを移植する
③ インデントを揃える
だるい
実際にwithCounterとwithKeydownを入れ替えてみるとよく分かる
理想的には1回の行入替で済んで欲しい
render propsを使う
副作用提供側のコンポーネントのrenderpropsに副作用利用側のコンポーネントを渡すパターン
発想は「関数でコンポーネントを包む = HoC」のではなく「React.ElementでReact.Elementを包む」
良い点
1. 副作用提供側のstateやメンバ変数の初期化が副作用利用側に露出していない
2. 再利用性が高い
3. props/state/インスタンス変数/インスタンスメソッド/lifecycleメソッドの衝突に強い
4. 同じ副作用を複数回利用できる
5. function componentから副作用を利用できる
6. 静的解析に優しい
7. HOCによるパターンよりも型注釈が簡潔
コンポーネントの代わりにReact.Elementを包むようになったため
副作用利用側のコンポーネントのpropsやstateの型をジェネリクスで表現する必要が無くなった
悪い点
1. ネストが深くなりがち
2. 副作用の適用順序の入れ替えが大変
childrenpropsについて
renderpropsでやったことをchildrenpropsでやるパターン
code:children-props.tsx
// before
<Counter
defaultCount={props.defaultCount}
render={(count, incrementCount) => (
<Keydown
listener={(e) => {
if (e.key === 'ArrowUp') incrementCount()
}}
render={<button onClick={incrementCount}>{count}</button>}
/>
)}
/>
// after
<Counter
defaultCount={props.defaultCount}
{(count, incrementCount) => (
<Keydown
listener={(e) => {
if (e.key === 'ArrowUp') incrementCount()
}}
{<button onClick={incrementCount}>{count}</button>}
</Keydown>
)}
</Counter>
Hooksを使う
良い点
1. 副作用提供側のstate等の初期化が副作用利用側に露出していない
2. 再利用性が高い
関数の中で閉じているので呼び出し側のコンポーネントに依存しない
3. props/state/インスタンス変数/インスタンスメソッド/lifecycleメソッドの衝突に強い
そもそもclass componentを使わないので発生のしようがない
4. 同じ副作用を複数回利用できる
5. function componentから副作用を利用できる
6. 静的解析に優しい
どの副作用を利用するかが宣言的に書かれている
7. 型注釈が簡潔
8. 副作用の適用順序を簡単に入れ替えられる
1回の行入替で済む
9. ネストが深くならない
10. あるひとまとまりの処理を関数に切り出すのと同じ感覚で副作用を分割できる
普段の関数分割の知識で副作用の分割に立ち向かえる
11. 副作用提供側をfunctionで書ける
HOCやrender propsでは内部にclass componentを使わなければならなかった
悪い点
1. Hooksが常に同じ順序で呼ばれるように書かなければならない
利用側が守らなければならない暗黙のルール
Hooksの裏側が表に露出してしまっている